package client_test

import (
	"context"
	"testing"

	abci "github.com/cometbft/cometbft/abci/types"
	cmtjson "github.com/cometbft/cometbft/libs/json"
	dbm "github.com/cosmos/cosmos-db"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/suite"
	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/metadata"

	"cosmossdk.io/depinject"
	"cosmossdk.io/log"
	"cosmossdk.io/math"

	"github.com/cosmos/cosmos-sdk/baseapp"
	"github.com/cosmos/cosmos-sdk/client"
	"github.com/cosmos/cosmos-sdk/codec"
	codectypes "github.com/cosmos/cosmos-sdk/codec/types"
	"github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1"
	"github.com/cosmos/cosmos-sdk/runtime"
	"github.com/cosmos/cosmos-sdk/server/config"
	"github.com/cosmos/cosmos-sdk/testutil/sims"
	"github.com/cosmos/cosmos-sdk/testutil/testdata"
	sdk "github.com/cosmos/cosmos-sdk/types"
	grpctypes "github.com/cosmos/cosmos-sdk/types/grpc"
	"github.com/cosmos/cosmos-sdk/x/auth/testutil"
	authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
	bankkeeper "github.com/cosmos/cosmos-sdk/x/bank/keeper"
	"github.com/cosmos/cosmos-sdk/x/bank/types"
)

type IntegrationTestSuite struct {
	suite.Suite

	ctx                   sdk.Context
	cdc                   codec.Codec
	genesisAccount        *authtypes.BaseAccount
	bankClient            types.QueryClient
	testClient            testdata.QueryClient
	genesisAccountBalance int64
}

func (s *IntegrationTestSuite) SetupSuite() {
	s.T().Log("setting up integration test suite")
	var (
		interfaceRegistry codectypes.InterfaceRegistry
		bankKeeper        bankkeeper.BaseKeeper
		appBuilder        *runtime.AppBuilder
		cdc               codec.Codec
	)

	// TODO duplicated from testutils/sims/app_helpers.go
	// need more composable startup options for simapp, this test needed a handle to the closed over genesis account
	// to query balances
	err := depinject.Inject(
		depinject.Configs(
			testutil.AppConfig,
			depinject.Supply(log.NewNopLogger()),
		),
		&interfaceRegistry, &bankKeeper, &appBuilder, &cdc)
	s.NoError(err)

	app := appBuilder.Build(dbm.NewMemDB(), nil)
	err = app.Load(true)
	s.NoError(err)

	valSet, err := sims.CreateRandomValidatorSet()
	s.NoError(err)

	// generate genesis account
	s.genesisAccountBalance = 100000000000000
	senderPrivKey := secp256k1.GenPrivKey()
	acc := authtypes.NewBaseAccount(senderPrivKey.PubKey().Address().Bytes(), senderPrivKey.PubKey(), 0, 0)
	balance := types.Balance{
		Address: acc.GetAddress().String(),
		Coins:   sdk.NewCoins(sdk.NewCoin(sdk.DefaultBondDenom, math.NewInt(s.genesisAccountBalance))),
	}

	genesisState, err := sims.GenesisStateWithValSet(cdc, app.DefaultGenesis(), valSet, []authtypes.GenesisAccount{acc}, balance)
	s.NoError(err)

	stateBytes, err := cmtjson.MarshalIndent(genesisState, "", " ")
	s.NoError(err)

	// init chain will set the validator set and initialize the genesis accounts
	_, err = app.InitChain(&abci.RequestInitChain{
		Validators:      []abci.ValidatorUpdate{},
		ConsensusParams: sims.DefaultConsensusParams,
		AppStateBytes:   stateBytes,
	})
	s.NoError(err)

	_, err = app.FinalizeBlock(&abci.RequestFinalizeBlock{
		Height:             app.LastBlockHeight() + 1,
		Hash:               app.LastCommitID().Hash,
		NextValidatorsHash: valSet.Hash(),
	})
	s.NoError(err)

	// end of app init

	s.ctx = app.NewContext(false)
	s.cdc = cdc
	queryHelper := baseapp.NewQueryServerTestHelper(s.ctx, interfaceRegistry)
	types.RegisterQueryServer(queryHelper, bankKeeper)
	testdata.RegisterQueryServer(queryHelper, testdata.QueryImpl{})
	s.bankClient = types.NewQueryClient(queryHelper)
	s.testClient = testdata.NewQueryClient(queryHelper)
	s.genesisAccount = acc
}

func (s *IntegrationTestSuite) TearDownSuite() {
	s.T().Log("tearing down integration test suite")
}

func (s *IntegrationTestSuite) TestGRPCQuery() {
	denom := sdk.DefaultBondDenom

	// gRPC query to test service should work
	testRes, err := s.testClient.Echo(context.Background(), &testdata.EchoRequest{Message: "hello"})
	s.Require().NoError(err)
	s.Require().Equal("hello", testRes.Message)

	// gRPC query to bank service should work
	var header metadata.MD
	res, err := s.bankClient.Balance(
		context.Background(),
		&types.QueryBalanceRequest{Address: s.genesisAccount.GetAddress().String(), Denom: denom},
		grpc.Header(&header), // Also fetch grpc header
	)
	s.Require().NoError(err)
	bal := res.GetBalance()
	s.Equal(sdk.NewCoin(denom, math.NewInt(s.genesisAccountBalance)), *bal)
}

func TestIntegrationTestSuite(t *testing.T) {
	suite.Run(t, new(IntegrationTestSuite))
}

func (s *IntegrationTestSuite) TestGetGRPCConnWithContext() {
	defaultConn, err := grpc.NewClient("localhost:9090",
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
	s.Require().NoError(err)
	defer defaultConn.Close()

	historicalConn, err := grpc.NewClient("localhost:9091",
		grpc.WithTransportCredentials(insecure.NewCredentials()),
	)
	s.Require().NoError(err)
	defer historicalConn.Close()

	historicalConns := config.HistoricalGRPCConnections{
		config.BlockRange{100, 500}: historicalConn,
	}
	provider := client.NewGRPCConnProvider(defaultConn, historicalConns)
	testCases := []struct {
		name         string
		height       int64
		setupCtx     func() client.Context
		expectedConn *grpc.ClientConn
	}{
		{
			name:   "context with GRPCConnProvider and historical height",
			height: 300,
			setupCtx: func() client.Context {
				return client.Context{}.
					WithCodec(s.cdc).
					WithGRPCClient(defaultConn).
					WithGRPCConnProvider(provider).
					WithHeight(300)
			},
			expectedConn: historicalConn,
		},
		{
			name:   "context with GRPCConnProvider and latest height",
			height: 0,
			setupCtx: func() client.Context {
				return client.Context{}.
					WithCodec(s.cdc).
					WithGRPCClient(defaultConn).
					WithGRPCConnProvider(provider).
					WithHeight(0)
			},
			expectedConn: defaultConn,
		},
		{
			name:   "context without GRPCConnProvider",
			height: 300,
			setupCtx: func() client.Context {
				return client.Context{}.
					WithCodec(s.cdc).
					WithGRPCClient(defaultConn).
					WithHeight(300)
			},
			expectedConn: defaultConn,
		},
		{
			name:   "context with nil historical connections map",
			height: 100,
			setupCtx: func() client.Context {
				nilProvider := client.NewGRPCConnProvider(defaultConn, nil)
				return client.Context{}.
					WithCodec(s.cdc).
					WithGRPCClient(defaultConn).
					WithGRPCConnProvider(nilProvider).
					WithHeight(100)
			},
			expectedConn: defaultConn,
		},
		{
			name:   "context with empty historical connections map",
			height: 100,
			setupCtx: func() client.Context {
				emptyProvider := client.NewGRPCConnProvider(defaultConn, config.HistoricalGRPCConnections{})
				return client.Context{}.
					WithCodec(s.cdc).
					WithGRPCClient(defaultConn).
					WithGRPCConnProvider(emptyProvider).
					WithHeight(100)
			},
			expectedConn: defaultConn,
		},
	}

	for _, tc := range testCases {
		s.Run(tc.name, func() {
			ctx := tc.setupCtx()
			var actualConn *grpc.ClientConn
			if ctx.GRPCConnProvider != nil {
				actualConn = ctx.GRPCConnProvider.GetGRPCConn(ctx.Height)
			} else {
				actualConn = ctx.GRPCClient
			}
			s.Require().Equal(tc.expectedConn, actualConn)
		})
	}
}

func TestGetHeightFromMetadata(t *testing.T) {
	tests := []struct {
		name           string
		setupContext   func() context.Context
		expectedHeight int64
	}{
		{
			name: "valid height in metadata",
			setupContext: func() context.Context {
				md := metadata.Pairs(grpctypes.GRPCBlockHeightHeader, "12345")
				return metadata.NewOutgoingContext(context.Background(), md)
			},
			expectedHeight: 12345,
		},
		{
			name: "zero height in metadata",
			setupContext: func() context.Context {
				md := metadata.Pairs(grpctypes.GRPCBlockHeightHeader, "0")
				return metadata.NewOutgoingContext(context.Background(), md)
			},
			expectedHeight: 0,
		},
		{
			name: "negative height returns zero",
			setupContext: func() context.Context {
				md := metadata.Pairs(grpctypes.GRPCBlockHeightHeader, "-100")
				return metadata.NewOutgoingContext(context.Background(), md)
			},
			expectedHeight: 0,
		},
		{
			name:           "no metadata returns zero",
			setupContext:   context.Background,
			expectedHeight: 0,
		},
		{
			name: "empty height header returns zero",
			setupContext: func() context.Context {
				md := metadata.New(map[string]string{})
				return metadata.NewOutgoingContext(context.Background(), md)
			},
			expectedHeight: 0,
		},
		{
			name: "invalid height string returns zero",
			setupContext: func() context.Context {
				md := metadata.Pairs(grpctypes.GRPCBlockHeightHeader, "not-a-number")
				return metadata.NewOutgoingContext(context.Background(), md)
			},
			expectedHeight: 0,
		},
		{
			name: "multiple height values uses first",
			setupContext: func() context.Context {
				md := metadata.Pairs(
					grpctypes.GRPCBlockHeightHeader, "100",
					grpctypes.GRPCBlockHeightHeader, "200",
				)
				return metadata.NewOutgoingContext(context.Background(), md)
			},
			expectedHeight: 100,
		},
		{
			name: "very large height",
			setupContext: func() context.Context {
				md := metadata.Pairs(grpctypes.GRPCBlockHeightHeader, "9223372036854775807") // max int64
				return metadata.NewOutgoingContext(context.Background(), md)
			},
			expectedHeight: 9223372036854775807,
		},
		{
			name: "height exceeding int64 returns zero",
			setupContext: func() context.Context {
				md := metadata.Pairs(grpctypes.GRPCBlockHeightHeader, "9223372036854775808") // max int64 + 1
				return metadata.NewOutgoingContext(context.Background(), md)
			},
			expectedHeight: 0,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctx := tt.setupContext()
			height := client.GetHeightFromMetadata(ctx)
			require.Equal(t, tt.expectedHeight, height)
		})
	}
}
